9. 测试

自动化测试被看成是Grails中一个重要部分,以 Groovy Tests 为基础执行测试。因此,Grails提供了许多方法,使不管是简单的单元测试,还是高难度的方法测试都能更容易执行。这个章节详细描述了Grails给出的各种不同的测试方法。

你要明白的第一件事是,所有create-*命令,实际上Grails最后都会自动帮它们创建集成好的全部测试实例。比如你运行下方的create-controller 命令:

grails create-controller simple

Grails不仅在grails-app/controllers/目录下创建了SimpleController.groovy,而且在test/integration/目录下创建了对它的集成测试实例SimpleControllerTests.groovy。,然而Grails不会在这个测试实例里自动生成逻辑代码,这部分需要你自己写。

当你完成这部分逻辑代码,就可以使用test-app执行所有测试实例:

grails test-app

上面的这个命令将输出如下内容:

-------------------------------------------------------
Running Unit Tests…
Running test FooTests...FAILURE
Unit Tests Completed in 464ms …
-------------------------------------------------------

Tests failed: 0 errors, 1 failures

同时运行结果放在test/reports目录下。你也可以指定名字单独运行一个测试,不需要测试后缀参数:

grails test-app SimpleController

除此之外,你可以以空格隔开同时运行多个实例:

grails test-app SimpleController BookController

9.1 单元测试

单元测试是对单元块代码的测试。换句话说你在分别测试各个方法或代码段时,不需要考虑它们外层周围代码结构。在Grails框架中,你要特别注意单元测试和集成测试之间的一个不同点,因为在单元测试中,Grails在集成测试和测试运行时,不会注入任何被调用的动态方法。

这样做是有意义的,假如你考虑到,在Grails中各个数据库注入的各自方法(通过使用GORM),和潜在使用的Servlet引擎(通过控制器)。例如,你在 BookController调用如下的一个服务应用:

class MyService {
    def otherService

String createSomething() { def stringId = otherService.newIdentifier() def item = new Item(code: stringId, name: "Bangle") item.save() return stringId }

int countItems(String name) { def items = Item.findAllByName(name) return items.size() } }

正如你看到的,这个应用调用了GORM,那么你用在单元测试中怎样处理如上这段代码呢?答案在Grails测试支持类中可以找到。

测试框架

Grails测试插件最核心部分是grails.test.GrailsUnitTestCase类。它是 GroovyTestCase子类,为Grails应用和组件提供测试工具。这个类为模拟特殊类型提供了若干方法,并且提供了按Groovy的MockFor和StubFor方式模拟的支持。

正常来说你在看之前所示的MyService例子和它对另外一个应用服务的依赖,以及例子中使用到的动态域类方法会有一点痛苦。你可以在这个例子中使用元类编程和“map as object”规则,但是很快你会发现使用这些方法会变得很糟糕,那我们要怎么用GrailsUnitTestCase写它的测试呢?

import grails.test.GrailsUnitTestCase

class MyServiceTests extends GrailsUnitTestCase { void testCreateSomething() { // Mock the domain class. def testInstances = [] mockDomain(Item, testInstances)

// Mock the "other" service. String testId = "NH-12347686" def otherControl = mockFor(OtherService) otherControl.demand.newIdentifier(1..1) {-> return testId }

// Initialise the service and test the target method. def testService = new MyService() testService.otherService = otherControl.createMock()

def retval = testService.createSomething()

// Check that the method returns the identifier returned by the // mock "other" service and also that a new Item instance has // been saved. assertEquals testId, retval assertEquals 1, testInstances assertTrue testInstances[0] instanceof Item }

void testCountItems() { // Mock the domain class, this time providing a list of test // Item instances that can be searched. def testInstances = [ new Item(code: "NH-4273997", name: "Laptop"), new Item(code: "EC-4395734", name: "Lamp"), new Item(code: "TF-4927324", name: "Laptop") ] mockDomain(Item, testInstances)

// Initialise the service and test the target method. def testService = new MyService()

assertEquals 2, testService.countItems("Laptop") assertEquals 1, testService.countItems("Lamp") assertEquals 0, testService.countItems("Chair") } }

上面代码出现了一些新的方法,但是一旦对它们进一步解释,你应该很快懂得要使用这些方法是多么容易。首先看testCreateSomething()测试方法里调用的mockDomain()方法,这是GrailsUnitTestCase类提供的其中一个方法:

def testInstances = [] 
mockDomain(Item, testInstances)

这个方法可以给给定的类添加所有共同域的方法(实例和静态),这样任何使用它的代码段都可以把它当作一个真正全面的domain 类。 举个例子,一旦Item类被模拟了,我们就可以在实例它的时候放心得调用save();那么这时,如果我们调用一个被模拟的domain类的这个方法,要怎么做?很简单,在testInstances数组列表里添加新的实例,这个数组被当成参数传进mockDomain()方法。

下面我们将重点讲解mockFor方法:

def otherControl = mockFor(OtherService) 
otherControl.demand.newIdentifier(1..1) {-> return testId }

这段代码功能与Groovy中的MockFor类和StubFor类非常接近,你可以用这个方法模拟任何类。事实上,上述demand语法跟MockFor和StubFor使用的语法一样,所以你在用它时应该不会觉得有差别,当然你要需要频繁注入一个mock实例作为关联,但是你可以简单得调用上述的mock控制对象的createMock()方法,很容易实现。对那些熟悉EasyMock用法的人,它们知道这是otherControl强调了mockFor()返回的对象角色,它是一个控制对象而非mock对象。

testCreateSomething()方法中剩余部分应该很熟悉了,特别是你现在已经知道了save()模拟方法是往testInstances数组里添加实例。我们能确定newIdentifier()模拟方法被调用,因为它返回的值对createSomething()方法返回结果产生直接的影响。那假如情况不是这样呢?我们怎么知道它是否被调用? 在MockFor类和StubFor类中,use()方法最后会做这个检查,但是testCreateSomething()方法中没有这个方法,然而你可以调用控制对象的verify()方法。在这个例子中,otherControl对象可以实现。这个方法会执行检查,在newIdentifier()应该被调用但没有被调用的情况下抛出诊断结果。

最后,这个例子中的testCountItems()向我们展示了mockDomain()方法的另外一个特性:

def testInstances = [ new Item(code: "NH-4273997", name: "Laptop"), 
                                          new Item(code: "EC-4395734", name: "Lamp"), 
                                          new Item(code: "TF-4927324", name: "Laptop") ] 
mockDomain(Item, testInstances)

通常手工模拟动态遍历器比较烦人,而且你经常不得不为每次执行设置不同的数组;除了这个之外,假如你决定使用一个不同的遍历器,你就不得不更新测试实例去测试新的方法。感谢mockDomain()方法为一组域实例的动态遍历器提供了一个轻量级的执行实现,把测试数据简单得作为这个方法的第二个参数,模拟遍历器就会工作了。

GrailsUnitTestCase - 模拟方法

你已经看过了一些介绍GrailsUnitTestCase中mock..()方法的例子。在这部分我们将详细地介绍所有GrailsUnitTestCase中提供的方法,首先以通用的mockFor()开始。在开始之前,有一个很重要的说明先说一下,使用这些方法可以保证对所给的类做出的任何改变都不会让其他测试实例受影响。这里有个普遍出现且严重的问题,当你尝试通过meta-class编程方法对它自身进行模拟,但是只要你对每个想模拟的类使用任何一个mock..()方法,这个问题就会消失了。

mockFor(class, loose = false)

万能的mockFor方法允许你对你一个类设置strict或loose请求。

这个方法很容易使用,默认情况下它会创建一个strict模式的mock控制对象,它的方法调用顺序非常重要,你可以使用这个对象详细定义各种需求:

def strictControl = mockFor(MyService)
strictControl.demand.someMethod(0..2) { String arg1, int arg2 -> … }
strictControl.demand.static.aStaticMethod {-> … }

注意你可以在demand后简单得使用static属性,就可以mock静态方法,然后定义你想mock的方法名字,一个可选的range范围作为它的参数。这个范围决定这个方法会被调用了多少次,所以假如这个方法的执行次数超过了这个范围,偏小或偏大,一个诊断异常就会被抛出。假如这个范围没有定义,默认的是使用“1..1”范围,比如上面定义的那个方法就只能被调用一次。

demand的最后一部分是closure,它代表了这个mock方法的实现部分。closure的参数列表应该与被mock方法的数量和类型相匹配,但是同时你可以随意在closure主体里添加你想要的代码。

像之前提到的,假如你想生成一个你正在模拟类的能用mock实例,你就需要调用mockControl.createMock()。事实上,你可以调用这个方法生成你想要的任何数量的mock实例。一旦执行了test方法,你就可以调用 mockControl.verify()方法检查你想要执行的方法执行了没。

最后,如下这个调用:

def looseControl = mockFor(MyService, true)

将生成一个含有loose特性的mock控制对象,比如方法调用的顺序不重要。

mockDomain(class, testInstances = )

这个方法选一个类作为它的参数,让所有domain类的非静态方法和静态方法的mock实现都可以在这个类调用到。

使用测试插件模拟domain类是其中的一个优势。手工模拟无论如何都是很麻烦的,所以mockDomain()方法帮你减轻这个负担是多么美妙。

实际上,mockDomain()方法提供了domain类的轻量级实现,database只是存储在内存里的一组domain实例。 所有的mock方法,save(),get(),findBy*()等都可以按你的期望在这组实例里运行。除了这些功能之外,save()和validate()模拟方法会执行真正的检查确认,包括对唯一的限制条件支持,它们会对相应的domain实例产生一个错误对象。

这里没什么其他要说了,除了插件不支持标准查询语句和HQL查询语句模拟。假如你想使用其中的一个,你可以简单得手工mock相应的方法,比如用mockFor()方法,或用真实的数据测试一个集成实例。

mockForConstraintsTests(class, testInstances = )

这个方法可以对domain类和command对象进行非常详细地模拟设置,它允许你确认各种约束是否按你想要的方式执行。

你测试domain约束了?如果没有,为什么没有?如果你的回答是它们不需要测试,请你三思。你的各种约束包含逻辑部分,这部分逻辑很容易产生bug,而这类bug很容易被捕捉到,特别的是save()允许失败也不会抛出异常。而如果你的回答是太难或太烦,现在这已经不再是借口了,可以用mockForConstraintsTests()解决这个问题。

这个方法就像mockDomain()方法的简化版本,简单得对所给的domain类添加一个validate()方法。你所要做的就是mock这个类,创建带有属性值的实例,然后调用validate()方法。你可以查看domain实例的errors属性判断这个确认方法是否失败。所以假如所有我们正在做的是模拟validate()方法,那么可选的测试实例数组参数呢?这就是我们为什么可以测试唯一约束的原因,你很快就可以看见了。

那么假设我们拥有如下的一个简单domain类:

class Book {
    String title
    String author

static constraints = { title(blank: false, unique: true) author(blank: false, minSize: 5) } }

不要担心这些约束是否合理,它们在这仅仅是示范作用。为了测试这些约束,我们可以按下面方法来做:

class BookTests extends GrailsUnitTestCase {
    void testConstraints() {
        def existingBook = new Book(title: "Misery", author: "Stephen King")
        mockForConstraintsTests(Book, [ existingBook ])

// Validation should fail if both properties are null. def book = new Book() assertFalse book.validate() assertEquals "nullable", book.errors["title"] assertEquals "nullable", book.errors["author"]

// So let's demonstrate the unique and minSize constraints. book = new Book(title: "Misery", author: "JK") assertFalse book.validate() assertEquals "unique", book.errors["title"] assertEquals "minSize", book.errors["author"]

// Validation should pass! book = new Book(title: "The Shining", author: "Stephen King") assertTrue book.validate() } }

你可以在没有进一步解释的情况下,阅读上面这些代码,思考它们正在做什么事情。我们会解释的唯一一件事是errors属性使用的方式。第一,它返回了真实的Spring Errors实例,所以你可以得到你通常期望的所有属性和方法。第二,这个特殊的Errors对象也可以用如上map/property方式使用。简单地读取你感兴趣的属性名字,map/property接口会返回被确认的约束名字。注意它是约束的名字,不是你所期望的信息内容。

这是测试约束讲解部分。我们要讲的最后一件事是用这种方式测试约束会捕捉一个共同的错误:typos in the "constraints" property。正常情况下这是目前最难捕捉的一个bug,还没有一个约束单元测试可以直接简单得发现这个问题。

mockLogging(class, enableDebug = false)

这个方法可以给一个类增加一个mock的log属性,任何传递给mock的logger的信息都会输出到控制台的。

mockController(class)

此方法可以为指定类添加mock版本的动态控制器属性和方法,通常它和ControllerUnitTestCase一起连用。

mockTagLib(class)

此方法可以为指定类添加mock版本的动态tablib属性和方法,通常它和TagLibUnitTestCase一起连用。

9.2 集成测试

集成测试与单元测试不同的是在测试实例内你拥有使用Grails环境的全部权限。Grails将使用一个内存内的HSQLDB数据库作为集成测试,清理每个测试之间的数据库的数据。

测试控制器

测试控制器之前你首先要了解Spring Mock Library。

实质上,Grails自动用 MockHttpServletRequestMockHttpServletResponse,和 MockHttpSession 配置每个测试实例,你可以使用它们执行你的测试用例。比如你可以考虑如下controller:

class FooController {

def text = { render "bar" }

def someRedirect = { redirect(action:"bar") } }

它的测试用例如下:

class FooControllerTests extends GroovyTestCase {

void testText() { def fc = new FooController() fc.text() assertEquals "bar", fc.response.contentAsString }

void testSomeRedirect() {

def fc = new FooController() fc.someRedirect() assertEquals "/foo/bar", fc.response.redirectedUrl } }

在上面的实例中,返回对象是一个MockHttpServletResponse实例,你可以使用这个实例获取写进返回对象的contentAsString值,或是跳转的URL。这些Servlet API的模拟版本全部都很更改,不像模拟之前那样子,因此你可以对请求对象设置属性,比如contextPath等。

Grails在集成测试期间调用actions不会自动执行interceptors,你要单独测试拦截器,必要的话通过functional testing测试。

用应用测试控制器

假如你的控制器引用了一个应用服务,你必须在测试实例里显示初始化这个应用。

举个使用应用的控制器例子:

class FilmStarsController {
    def popularityService

def update = { // do something with popularityService } }

相应的测试实例:

class FilmStarsTests extends GroovyTestCase {
    def popularityService

public void testInjectedServiceInController () { def fsc = new FilmStarsController() fsc.popularityService = popularityService fsc.update() } }

测试控制器command对象

使用command对象,你可以给请求对象request提供参数,当你调用没有带参数的action处理对象时,它会自动为你做command对象工作。

举个带有command对象的控制器例子:

class AuthenticationController {
    def signup = { SignupForm form ->
        …
    }
}

你可以如下对它进行测试:

def controller = new AuthenticationController()
controller.params.login = "marcpalmer"
controller.params.password = "secret"
controller.params.passwordConfirm = "secret"
controller.signup()

Grails把signup()的调用自动当作对处理对象的调用,利用模拟请求参数生成command对象。在控制器测试期间,params通过Grails的模拟请求对象是可更改的。

测试控制器和render方法

render方法允许你在一个action主体内的任何一个地方显示一个定制的视图。例如,考虑如下的例子:

def save = {
        def book = Book(params)
        if(book.save()) {
                // handle
        }
        else {
                render(view:"create", model:[book:book])
        }
}

上面举的这个例子中,处理对象用返回值作这个模型的结果是不可行的,相反结果保存在控制对象的modelAndView属性当中。modelAndView属性是Spring MVC ModelAndView类的一个实例,你可以用它测试一个action处理后的结果:

def bookController = new BookController()
bookController.save()
def model = bookController.modelAndView.model.book

模拟生成请求数据

如果你测试一个action请求处理对象需要类似REST web应用的请求参数,你可以使用Spring MockHttpServletRequest对象实现。例如,考虑如下这个action,它执行一个进来请求的数据邦定:

def create = {
        [book: new Book(params['book']) ] 
}

假如你想把book参数模拟成一个XML请求对象,你可以按如下方法做:

void testCreateWithXML() {
        def controller = new BookController()
        controller.request.contentType = 'text/xml'
        controller.request.contents = '''<?xml version="1.0" encoding="ISO-8859-1"?>
        <book>
                <title>The Stand</title>
                …
        </book> 
        '''.getBytes() // note we need the bytes

def model = controller.create() assert model.book assertEquals "The Stand", model.book.title }

同样你可以通过JSON对象达到这个目的:

void testCreateWithJSON() {
        def controller = new BookController()     
        controller.request.contentType = "text/json"
        controller.request.content = '{"id":1,"class":"Book","title":"The Stand"}'.getBytes()

def model = controller.create() assert model.book assertEquals "The Stand", model.book.title

}

使用JSON,也不要忘记对class属性指定名字,绑定的目标类型。在XML里,在book节点内这些设置隐含的,但是使用JSON你需要这个属性作为JSON包的一部分。

更多关于REST web应用的信息,可以参考REST章节。

测试Web Flows

测试Web Flows需要一个特殊的测试工具grails.test.WebFlowTestCase,它继承Spring Web Flow的AbstractFlowExecutionTests 类。Testing Web Flows requires a special test harness called grails.test.WebFlowTestCase which sub classes Spring Web Flow's AbstractFlowExecutionTests class.

WebFlowTestCase子类必须是集成测试实例Subclasses of WebFlowTestCase must be integration tests

例如在下面的这个小flow情况下:

class ExampleController {
        def exampleFlow = {
                start {
                        on("go") {
                                flow.hello = "world"
                        }.to "next"
                }
                next {
                        on("back").to "start"
                        on("go").to "end"
                }
                end()
        }  
}

接着你需要让测试工具知道使用什么样的flow定义。通过重载getFlow抽象方法可以实现:

class ExampleFlowTests extends grails.test.WebFlowTestCase {
        def getFlow() { new ExampleController().exampleFlow }
        …
}

假如你需要指定一个flow标识,你可以通过重载getFlowId方法实现,同时默认情况下是一个测试实例:

class ExampleFlowTests extends grails.test.WebFlowTestCase {
        String getFlowId() { "example" }
        …
}

一旦这在你的测试实例里实现了,你需要用startFlow方法开始启动这个flow,这个方法会返回ViewSelection对象:

void testExampleFlow() {
        def viewSelection = startFlow()

assertEquals "start", viewSelection.viewName … }

如上所示,你可以通过使用ViewSelection对象的viewName属性,检查你是否是正确的。触发事件你需要使用signalEvent方法:

void testExampleFlow() {
        …
        viewSelection = signalEvent("go")
        assertEquals "next", viewSelection.viewName
        assertEquals "world", viewSelection.model.hello
}

这里我们可以给flow发送信号执行go事件,这导致了到next状态的转变。在上面的这个例子中转变的结果把一个hello变量放进flow范围。我们可以检查如上ViewSelection的model属性测试这个变量的值。

测试标签库

其实测试标签库是一件很容易的事,因为当一个标签被当作一个方法执行时,它会返回一个字符串值。所以例如你拥有如下的一个标签库:

class FooTagLib {
   def bar =  { attrs, body ->
           out << "<p>Hello World!</p>"
   }

def bodyTag = { attrs, body -> out << "<${attrs.name}>" out << body() out << "</${attrs.name}>" } }

相应的测试如下:

class FooTagLibTests extends GroovyTestCase {

void testBarTag() { assertEquals "<p>Hello World!</p>", new FooTagLib().bar(null,null) }

void testBodyTag() { assertEquals "<p>Hello World!</p>", new FooTagLib().bodyTag(name:"p") { "Hello World!" } } }

注意在第二个例子的testBodyTag中,我们传递了返回标签主体内容的代码块作为内容,把标签主体内容作为字符串比较方便。

使用GroovyPagesTestCase测试标签库

除了上述简单的标签库测试方法之外,你也可以使用grails.test.GroovyPagesTestCase类测试标签库。

GroovyPagesTestCase类是常见GroovyTestCase的子类,它为GSP显示输出提供实用方法。

GroovyPagesTestCase类只能在集成测试中使用。

举个时间格式化标签库的例子,如下:

class FormatTagLib {
        def dateFormat = { attrs, body -> 
                out << new java.text.SimpleDateFormat(attrs.format) << attrs.date
        }
}

可以按如下方法进行测试:

class FormatTagLibTests extends GroovyPagesTestCase {
        void testDateFormat() {
                def template = '<g:dateFormat format="dd-MM-yyyy" date="${myDate}" />'

def testDate = … // create the date assertOutputEquals( '01-01-2008', template, [myDate:testDate] ) } }

你也可以使用GroovyPagesTestCase的applyTemplate方法获取GSP的输出结果:

class FormatTagLibTests extends GroovyPagesTestCase {
        void testDateFormat() {
                def template = '<g:dateFormat format="dd-MM-yyyy" date="${myDate}" />'

def testDate = … // create the date def result = applyTemplate( template, [myDate:testDate] )

assertEquals '01-01-2008', result } }

测试Domain类

用GORM API测试domain类是一件很简单的事情,然而你还要注意一些事项。第一,假如你在测试查询语句,你将经常需要flush以便保证正确的状态持久保存到数据库。比如下面的一个例子:

void testQuery() {
        def books = [ new Book(title:"The Stand"), new Book(title:"The Shining")]
        books*.save()

assertEquals 2, Book.list().size() }

这个测试实际上会失败,因为调用save方法的时候,save方法不会真的持久保存book实例。调用save方法仅仅是向Hibernate暗示在将来的某个时候这些实例应该会被保存。假如你希望立即提交这些改变,你需要flush它们:

void testQuery() {
        def books = [ new Book(title:"The Stand"), new Book(title:"The Shining")]
        books*.save(flush:true)

assertEquals 2, Book.list().size() }

在这个案例中我们传递了flush的true值作为参数,更新将马上被保存,因此对此后的查询语句也有效。

9.3 功能测试

功能测试是测试正在运行的应用,经常自动化较难实现。Grails没有发布任何功能测试开箱即用支持,但是通过插件实现了对 Canoo WebTest 的支持。

首先按如下的命令按照Web Test:

grails install-plugin webtest

参考reference on the wiki ,它里面了解释怎么使用Web Test和Grails。 Show details